https://medium.com/@okanyenigun/building-a-neural-network-from-scratch-in-python-a-step-by-step-guide-8f8cab064c8a

# Building a Neural Network from Scratch in Python: A Step-by-Step Guide

## Linear and Non-linear


## 1 Activation Functions

```
import numpy as np
import matplotlib.pyplot as plt

def plot_func(x,y, title):
    """Вспомогательная функция для построения графиков функций активации.

    Args:
        x (numpy.ndarray): Массив значений по оси x.
        y (numpy.ndarray): Массив значений функции активации, соответствующих значениям x.
        title (str): Заголовок графика.
    """

    plt.plot(x, y)  # Построить график зависимости y от x.
    plt.title(title)  # Установить заголовок графика.
    plt.xlabel('x')  # Подписать ось x меткой "x".
    plt.ylabel('activation(x)')  # Подписать ось y меткой "activation(x)".
    plt.grid(True)  # Включить сетку на графике.
    plt.show()     # Отобразить график.

x = np.linspace(-10, 10, 100)
```

Библиотека Matplotlib (plt): построение различных типов визуализаций (графиков, диаграмм и т.д.).
Библиотека NumPy (np): функции для работы с многомерными массивами и математическими операциями.

np.linspace(-10, 10, 100): Создает массив x с помощью функции linspace из библиотеки NumPy. Эта функция генерирует 100 значений, равномерно распределенных между -10 (включительно) и 10 (включительно).

Использование функции plot_func (предполагается, что у вас есть массив значений функции активации y):

plot_func(x, y, "График функции активации"): Вызывает функцию plot_func, передавая ей созданный массив x, массив значений функции активации (y) и заголовок графика ("График функции активации").

## 2. Sigmoid

Функция сигмоидной активации - это математическая функция, которая отображает входное значение в значение между 0 и 1, образуя гладкую S-образную кривую.

```
def sigmoid(x):
    return 1 / (1 + np.exp(-x))
```

![Sigmoid](./resources/sigmoid.webp)

Функция активации ReLU (выпрямленная линейная единица) - это математическая функция, которая возвращает входное значение, если оно положительное, и ноль в противном случае, обеспечивая кусочно-линейный выход.

## 3. Rectified Linear Unit (ReLU)

```
def relu(x):
    return np.maximum(0, x)
```

![Rectified Linear Unit (ReLU)](./resources/ReLU.webp)

Функция активации ReLU (выпрямленная линейная единица) - это математическая функция, которая возвращает входное значение, если оно положительное, и ноль в противном случае, обеспечивая кусочно-линейный выход.

## 4. Leaky ReLU

The Leaky ReLU activation function is a variation of the ReLU function that allows a small, non-zero output for negative input values, preventing complete suppression of information.

Функция активации Leaky ReLU является модификацией функции ReLU, которая допускает небольшой ненулевой выход для отрицательных входных значений, предотвращая тем самым полное подавление информации.



```
def relu(x):
    return np.maximum(0, x)
```


![Leaky relu function](./resources/leakyReLU.webp)

## 5. Tanh

Функция активации tanh (гиперболический тангенс) - это математическая функция, которая отображает входное значение в диапазон от -1 до 1, образуя гладкую S-образную кривую, центрированную вокруг нуля.

```
def tanh(x):
    return np.tanh(x)
```
![Функция активации tanh (гиперболический тангенс)](./resources/tahn.webp)


## 6. Softmax

The softmax activation function is a mathematical function commonly used in multiclass classification tasks to convert a vector of real values into a probability distribution over multiple classes.

Функция активации softmax - это математическая функция, широко используемая в задачах многоклассовой классификации для преобразования вектора действительных чисел в вероятностное распределение по нескольким классам.

```
def softmax(x):
    exp_scores = np.exp(x)
    return exp_scores / np.sum(exp_scores)
```

![Softmax](./resources/softmax.webp)

We will go on with the ReLU activation function in our implementation.
В нашей реализации мы продолжим использовать функцию активации ReLU.

## 7. Multiple Layers
Certainly, solely incorporating the ReLU activation function would significantly amplify the error. This is primarily because it would produce identical predictions for any input below zero. To address this issue, we introduce multiple layers in our neural network. For instance, a neural network with two layers can be represented by the equation y^ = w2 * relu(w1 * x + b1) + b2.


Absolutely, использование только функции активации ReLU значительно увеличит ошибку. Прежде всего, это связано с тем, что для любого входного значения меньше нуля она будет выдавать одинаковые предсказания. Для решения этой проблемы мы вводим несколько слоев в нашу нейронную сеть. Например, нейронную сеть с двумя слоями можно представить уравнением y^ = w2 * relu(w1 * x + b1) + b2.


## 8. Multiple Hidden Units

A lambda function, prediction, calculates a linear prediction based on the input x, using predefined values for the weights (w1) and bias (b).


В данном случае, функция lambda, названная prediction (предсказание), рассчитывает линейное предсказание на основе входного значения x. При этом она использует заранее определённые веса (w1) и смещение (b).

```
prediction = lambda x, w1=.2, b=1.99: x * w1 + b
```

Then, we apply the ReLU activation function to the linear predictions.

Затем, к полученным линейным предсказаниям мы применяем функцию активации ReLU.

```
layer1_1 = np.maximum(0, prediction(x))
plt.plot(x, layer1_1)
```

![layer1_1](./resources/layer1_1.webp)

What happens if we add another layer?


```
layer1_2 = np.maximum(0, prediction(x, .3, -2))
plt.plot(x, layer1_1+layer1_2)
```

![layer1_2](./resources/layer1_2.webp)

We introduced a nonlinearity in the output. Let’s add one more.

```
layer1_3 = np.maximum(0, prediction(x, .6, -2))
plt.plot(x, layer1_1+layer1_2+layer1_3)
```

![layer1_3](./resources/layer1_3.webp)

By increasing the number of units, we observe the emergence of a more pronounced non-linear relationship. As we adjust the weights, we can observe corresponding changes in the relationship.

Let’s draw a diagram for a two-layer structure.

По мере увеличения количества единиц, мы наблюдаем появление более выраженной нелинейной зависимости. По мере того, как мы регулируем веса, мы можем наблюдать соответствующие изменения в зависимости.

Давайте построим диаграмму для двуслойной структуры.

![Diagram for a two-layer structure](./resources/diagram.webp)

## 9. Calculating Outputs

As evident from the above, the output of one component serves as the input for another. Matrix multiplication is employed to compute the outputs in this process.

Как видно из вышесказанного, результат одного компонента служит входным сигналом для другого. Для вычисления выходных значений в этом процессе используется матричное умножение.

![Matrix multiplication](./resources/calculatingOutput1.gif)

Suppose we have an input represented by a matrix of shape 2x1.

Допустим, у нас есть входные данные, представленные матрицей размерности 2x1.

![Matrix multiplication](./resources/calculatingOutput2.webp)

## 10. Forward Pass

[tsensor package](https://github.com/parrt/tensor-sensor)

The tsensor package can be employed to visualize tensor variables effectively. By enhancing error messages and displaying Python code, TensorSensor provides insights into the shape of tensor variables. It is compatible with popular libraries such as TensorFlow, PyTorch, JAX, Numpy, Keras, and fastai.

Пакет tsensor может эффективно использоваться для визуализации тензорных переменных. TensorSensor улучшает понимание формы тензорных переменных за счет более информативных сообщений об ошибках и отображения исходного кода Python. Он совместим с популярными библиотеками, такими как TensorFlow, PyTorch, JAX, NumPy, Keras и fastai.

```
from tsensor import explain as exp

x_input = np.array([[10], [20], [-20], [-40], [-3]])

# 1x2 weight matrix
l1_weights = np.array([[.73, .2]])

# 1x2 bias matrix
l1_bias = np.array([[4, 2]])

# output
with exp() as c:
    l1_output = x_input @ l1_weights + l1_bias
```

![tsensor output](./resources/forwardPass1.webp)

![outputs](./resources/forwardPass2.webp)

We apply an activation function to the output of the aforementioned process.

Затем к выходным данным этого процесса мы применяем функцию активации.

```
l1_activated = relu(l1_output)
```

![l1 activated](./resources/forwardPass3.webp)

![](./resources/forwardPass4.webp)

A useful guideline is that the number of rows in the weight matrix should match the number of columns in the input matrix, while the number of columns in the weight matrix should match the number of columns in the output matrix. Consequently, in layer one, our weight matrix is 1x2, signifying a transition from 1 input feature to 2 output features.

Полезное правило заключается в том, что количество строк в весовой матрице должно совпадать с количеством столбцов во входной матрице, а количество столбцов в весовой матрице должно совпадать с количеством столбцов в выходной матрице. Следовательно, в первом слое наша весовая матрица имеет размерность 1x2, что означает переход от 1 входного признака к 2 выходным признакам.

![](./resources/forwardPass5.webp)


![](./resources/forwardPass6.webp)


Essentially, this is the fundamental process of making predictions in neural networks. It involves utilizing the weight matrix and bias matrix for each layer, performing repeated multiplications with the weight matrix, incorporating the bias, applying a non-linearity, and repeating this process for each layer. To accommodate additional units within a layer, you can simply add more columns to the weight matrix.

В сущности, это фундаментальный процесс создания предсказаний в нейронных сетях. Он включает в себя использование матрицы весов и матрицы смещения для каждого слоя, выполнение повторяющихся умножений на матрицу весов, добавление смещения, применение нелинейности и повторение этого процесса для каждого слоя. Чтобы учесть дополнительные единицы внутри слоя, вы можете просто добавить больше столбцов в матрицу весо

Основополагающий процесс создания предсказаний в нейронных сетях. Он действительно включает в себя:

* Использование весовой матрицы и матрицы смещения для каждого слоя: Весовая матрица определяет силу связей между нейронами, а матрица смещения регулирует выход каждого нейрона.
* Многократное перемножение на весовую матрицу: Каждый элемент входного вектора перемножается на соответствующие веса, полученные из весовой матрицы.
* Добавление смещения: К полученному после перемножения значению добавляется смещение.
* Применение функции активации: Функция активации вводит нелинейность в сеть, позволяя ей моделировать более сложные зависимости.
* Повторение для каждого слоя: Весь процесс - перемножение, смещение, активация - повторяется для каждого слоя в нейронной сети.
* Увеличение размерности весовой матрицы: Чтобы учесть большее количество нейронов в слое, просто добавляются столбцы в весовую матрицу.


## 11. Loss

Now, we can use mean squared error (or any other metric) to calculate the error between our output and the actual values.

Теперь мы можем использовать среднеквадратическую ошибку (или любую другую метрику) для расчета ошибки между нашим выходным сигналом и фактическими значениями.

```
def calculate_mse(actual, predicted):
    return (actual - predicted) ** 2

actual = np.array([[9], [13], [5], [-2], [-1]])

print(calculate_mse(actual,output))

"""
[[6.4000000e-03]
 [9.2160000e-01]
 [1.0000000e+00]
 [3.6000000e+01]
 [3.4386496e+01]]
"""
```

During gradient descent, it is essential to determine the gradient of the loss function, which represents the rate of change. This gradient indicates how the loss function alters as we modify the input values.

Во время градиентного спуска  критически важно определить градиент функции потерь, который представляет собой скорость изменения. Этот градиент показывает, как функция потерь изменяется по мере того, как мы меняем входные значения.

```
def gradient_mse(actual, predicted):
    return predicted - actual

print(gradient_mse(actual,output))

"""
[[-0.08 ]
 [-0.96 ]
 [-1.   ]
 [ 6.   ]
 [ 5.864]]
"""
```

This information guides the necessary adjustment to our prediction in order to minimize the error effectively.

Эта информация  показывает направление необходимой корректировки нашего прогноза для эффективного минимизации ошибки.



## 12. Backward Pass Обратное распространение (обратный проход)

Backpropagation, in essence, reverses the forward pass to distribute the gradient to the different parameters of the network, such as weights and biases. This gradient plays a crucial role in enabling the network to learn through the utilization of gradient descent.


Обратное распространение (backpropagation), по сути, обращает прямой проход, чтобы распределить градиент по различным параметрам сети, таким как веса и смещения. Этот градиент играет решающую роль в обучении сети с помощью алгоритма градиентного спуска.

Forward pass:

Прямой проход:

![](./resources/backwardPass1.webp)

We basically reverse this pass to get a backward pass.

Мы разворачиваем этот процесс, чтобы получить обратное распространение.

![](./resources/backwardPass2.webp)

We compute the partial derivative of the loss function for each parameter. The gradient of the output, referred to as the L2 output gradient, is used to update the weights in layer 2. We perform this update by multiplying the input to layer 2 and the output from layer 1 in the forward pass by the L2 output gradient. The bias is updated by taking the average of the output gradient.

Вычисляем частную производную функции потерь для каждого параметра сети. Градиент выходного слоя, также называемый градиентом L2, используется для обновления весов во втором слое. Обновление выполняется путем перемножения входных данных второго слоя и выходных данных первого слоя (из прямого прохода) с градиентом L2. Смещение в слое обновляется путем взятия среднего значения градиента по выходу.

Next, we propagate the gradient to layer one by multiplying the output gradient by the layer 2 weights. We then pass this gradient through the ReLU function and utilize it to update the weights and bias in layer 1.

Далее, мы распространяем градиент на первый слой, умножая его на веса второго слоя. Затем пропускаем этот градиент через функцию ReLU и используем его для обновления весов и смещения в первом слое.

```
output_gradient = gradient_mse(actual, output)

with exp():
    l2_w_gradient =  l1_activated.T @ output_gradient
l2_w_gradient

"""
array([[-8.14616],
       [ 2.1296 ]])
"""
```

![](./resources/backwardPass3.webp)

We took the transpose of the 5x2 l1_activated matrix (now it is 2x5), which was the output of layer 1 in the forward pass, and multiplied it by our 5x1 output_gradient to obtain a 2x1 matrix. This matrix gives us the gradient of each value in our w2 matrix.

Взяли транспонированную матрицу l1_activated размером 5x2 (теперь она стала 2x5), которая была выходным сигналом 1-го слоя в прямом проходе, и перемножили ее с нашим градиентом выходного слоя output_gradient размером 5x1. В результате получили матрицу размером 2x1. Эта матрица показывает нам градиент для каждого значения в нашей матрице весов w2.

![](./resources/backwardPass4.webp)

The diagram provided illustrates how the inputs are multiplied by the weights during the forward pass to generate the output. It is evident that each weight is associated with multiple inputs, and each weight is connected to both multiple inputs and multiple outputs.

Предоставленная диаграмма показывает, как входы умножаются на веса во время прямого прохода для получения выходного сигнала.  Очевидно, что каждому весу соответствует несколько входов, а также каждый вес связан как с несколькими входами, так и с несколькими выходами.

![](./resources/backwardPass5.webp)

We transpose the output of layer 1 and multiply it by the output gradient to obtain the weight gradient.

Для получения градиента весов мы транспонируем выход слоя 1 и перемножаем его с градиентом выхода.

![](./resources/backwardPass6.webp)

o ensure that each input connected to an output through a weight is multiplied by the corresponding output gradient, we transpose the layer one output matrix (which serves as the input to layer 2).

Для того, чтобы каждый входной элемент, связанный с выходом через вес, был умножен на соответствующий градиент выхода, мы транспонируем матрицу выходных значений первого слоя (которая является входными данными для второго слоя).

By multiplying the output gradients with the inputs to the layer, we can determine the necessary adjustments to our weights. This relationship arises from the application of the chain rule of partial derivatives.

Перемножая градиенты выхода с входными данными слоя, мы можем определить необходимые корректировки весов. Эта связь возникает из применения правила цепочки для частных производных.

![](./resources/backwardPass7.webp)

By applying the chain rule of partial derivatives, we compute the partial derivative of the loss for the second weight matrix. This involves multiplying the partial derivative of the loss by the partial derivative of the product of x and w2 concerning w2. Solving this equation allows us to determine the appropriate values for the weight matrix.

С помощью правила цепочки для частных производных мы вычисляем частную производную ошибки по отношению к второй матрице весов.  Это включает в себя умножение частной производной ошибки на частную производную произведения x и w2 по w2. Решение этого уравнения позволяет нам определить подходящие значения для матрицы весов.

Next, we calculate the derivative for the bias.

Далее, мы вычисляем производную для смещения.

```
with exp():
    l2_b_gradient =  np.mean(output_gradient, axis=0)

l2_b_gradient

"""
array([1.9648])
"""
```

![](./resources/backwardPass8.webp)

To update the weights and biases in layer 2, we subtract the gradient from the current values of w and b, scaled by the learning rate. The learning rate helps to prevent updates that are too large, which could cause us to move away from the optimal solution with the lowest error.

Для обновления весов и смещения в слое 2 мы вычитаем из их текущих значений (w и b) градиент, умноженный на коэффициент обучения (learning rate). Коэффициент обучения помогает предотвратить слишком большие обновления, которые могут привести к тому, что мы отойдем от оптимального решения с минимальной ошибкой.

```
# Set a learning rate
lr = 1e-4

with exp():
    # Update the bias values
    l2_bias = l2_bias - l2_b_gradient * lr
    # Update the weight values
    l2_weights = l2_weights - l2_w_gradient * lr

l2_weights

"""
array([[0.40171069],
       [0.09955278]])
"""
```

We update the l2 bias and l2 weights.


![](./resources/backwardPass9.webp)


![](./resources/backwardPass10.webp)



## 13. Layer 1 Gradients

Next, we proceed to compute the gradients for layer 1. The layer 1 outputs are obtained by scaling the inputs with the corresponding weights, resulting in the layer 2 output. To determine the gradient of the loss with respect to the layer 1 output, we reverse the forward pass by scaling the output gradient with the weights of layer 2.

Далее мы переходим к вычислению градиентов для слоя 1. Выходные значения слоя 1 получаются путем масштабирования входных данных соответствующими весами, что приводит к выходным данным слоя 2. Чтобы определить градиент ошибки по отношению к выходным данным слоя 1, мы обращаем прямой проход, масштабируя градиент выхода весами слоя 2.

```
with exp():
    # Calculate the gradient on the output of layer 1
    l1_activated_gradient = output_gradient @ l2_weights.T

l1_activated_gradient

"""
array([[-0.03213686, -0.00796422],
       [-0.38564227, -0.09557067],
       [-0.40171069, -0.09955278],
       [ 2.41026416,  0.5973167 ],
       [ 2.35563151,  0.58377753]])
"""
```

![](./resources/layer1Gradients1.webp)

Moving on, we proceed to compute the gradients for the layer 1 weight and biases. Initially, we need to propagate the gradient through the ReLU non-linearity. To achieve this, we consider the derivative of the ReLU function.

Переходя далее, мы приступаем к вычислению градиентов для весов и смещения слоя 1. Сначала нам нужно про propagated (пропагировать) градиент через нелинейность ReLU. Для этого мы рассмотрим производную функции ReLU.

![](./resources/layer1Gradients2.webp)

In our case, the slope is 0 or 1. So, the derivative of the relu function is either 0 or 1.

```
with exp():
    l1_output_gradient = l1_activated_gradient * np.heaviside(l1_output, 0)

l1_output_gradient

"""
array([[-0.03213686, -0.00796422],
       [-0.38564227, -0.09557067],
       [-0.        , -0.        ],
       [ 0.        ,  0.        ],
       [ 2.35563151,  0.58377753]])
"""
```

![](./resources/layer1Gradients3.webp)

Now, we calculate the layer 1 gradient.

Переходим к расчету градиента слоя 1.

```
# back propagation
l1_w_gradient =  input.T @ l1_output_gradient
l1_b_gradient = np.mean(l1_output_gradient, axis=0)

# gradient descent
l1_weights -= l1_w_gradient * lr
l1_bias -= l1_b_gradient * lr
```

In the previous step, we computed the gradients for layer 1 and used them to update the weight and bias values. Essentially, we performed backpropagation through two layers and applied gradient descent in each of them.

В предыдущем шаге мы вычислили градиенты для слоя 1 и использовали их для обновления значений весов и смещения. По сути, мы выполнили обратное распространение (backpropagation) через два слоя и применили градиентный спуск в каждом из них.


## 14. Training

Below is the algorithm we employed:

1. Perform the forward pass through the network and obtain the output.
2. Calculate the gradient for the network outputs using the mse_grad function.
3. For each layer in the network:
    * Determine the gradient for the pre-nonlinearity output (if the layer includes a nonlinearity).
    * Calculate the gradient for the weights.
    * Compute the gradient for the biases.
    * Determine the gradient for the inputs to the layer.
4. Update the network parameters using gradient descent.


Вот алгоритм, который мы использовали:

1. Выполнить прямой проход через сеть и получить выходной сигнал.
2. Вычислить градиент для выходных данных сети с помощью функции mse_grad.
3. Для каждого слоя в сети:
    * Определить градиент для выхода до применения нелинейности (если слой включает нелинейность).
    * Вычислить градиент для весов.
    * Вычислить градиент для смещений.
    * Определить градиент для входных данных слоя.
4. Обновить параметры сети с помощью метода градиентного спуска.

For simplicity, we consolidated step 4 into step 3. However, it is crucial to understand that backpropagation corresponds to step 3, while gradient descent corresponds to step 4. By separating these steps, it becomes more convenient to employ different variations of gradient descent, such as Adam or RMSProp, to update the weights. Stages 3 and 4 are commonly referred to as the backward pass of a neural network.


Для упрощения мы объединили шаг 4 с шагом 3. Однако, важно понимать, что обратное распространение (backpropagation) соответствует шагу 3, а градиентный спуск - шагу 4.
Разделяя эти шаги, становится более удобным использовать различные варианты градиентного спуска, такие как Adam или RMSProp, для обновления весов. Шаги 3 и 4 обычно называются обратным проходом (backward pass) нейронной сети.


Backpropagation and gradient descent represent the more intricate aspects of training neural networks. Conceptually, backpropagation involves reversing the forward pass of the network to determine the most effective means of minimizing error. This entails propagating the gradient of the loss from one layer to another and applying the chain rule along the way.

Обратное распространение и градиентный спуск представляют собой наиболее сложные аспекты обучения нейронных сетей.
Концептуально, обратное распространение подразумевает обращение прямого прохода сети для определения наиболее эффективного способа минимизации ошибки. Это влечет за собой распространение градиента ошибки от одного слоя к другому и применение правила цепочки по пути.


## 15. Code

In the following section, we have consolidated all the aforementioned topics into a class called Neural.

В следующем разделе мы объединили все вышеупомянутые темы в класс под названием Neural.

```
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error
from statistics import mean
from typing import Dict, List, Tuple

np.random.seed(42)

class Neural:
    
    def __init__(self, layers: List[int], epochs: int, 
                 learning_rate: float = 0.001, batch_size: int=32,
                 validation_split: float = 0.2, verbose: int=1):
        self._layer_structure: List[int] = layers
        self._batch_size: int = batch_size
        self._epochs: int = epochs
        self._learning_rate: float = learning_rate
        self._validation_split: float = validation_split
        self._verbose: int = verbose
        self._losses: Dict[str, float] = {"train": [], "validation": []}
        self._is_fit: bool = False
        self.__layers = None
        
    def fit(self, X: np.ndarray, y: np.ndarray) -> None:
        # validation split
        X, X_val, y, y_val = train_test_split(X, y, test_size=self._validation_split, random_state=42)
        # initialization of layers
        self.__layers = self.__init_layers()
        for epoch in range(self._epochs):
            epoch_losses = []
            for i in range(1, len(self.__layers)):
                # forward pass
                x_batch = X[i:(i+self._batch_size)]
                y_batch = y[i:(i+self._batch_size)]
                pred, hidden = self.__forward(x_batch)
                # calculate loss
                loss = self.__calculate_loss(y_batch, pred)
                epoch_losses.append(np.mean(loss ** 2))
                #backward
                self.__backward(hidden, loss)
            valid_preds, _ = self.__forward(X_val)
            train_loss = mean(epoch_losses)
            valid_loss = np.mean(self.__calculate_mse(valid_preds,y_val))
            self._losses["train"].append(train_loss)
            self._losses["validation"].append(valid_loss)
            if self._verbose:
                print(f"Epoch: {epoch} Train MSE: {train_loss} Valid MSE: {valid_loss}")
        self._is_fit = True
        return
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        if self._is_fit == False:
            raise Exception("Model has not been trained yet.")
        pred, hidden = self.__forward(X)
        return pred
    
    def plot_learning(self) -> None:
        plt.plot(self._losses["train"],label="loss")
        plt.plot(self._losses["validation"],label="validation")
        plt.legend()
    
    def __init_layers(self) -> List[np.ndarray]:
        layers = []
        for i in range(1, len(self._layer_structure)):
            layers.append([
                np.random.rand(self._layer_structure[i-1], self._layer_structure[i]) / 5 - .1,
                np.ones((1,self._layer_structure[i]))
            ])
        return layers
    
    def __forward(self, batch: np.ndarray) -> Tuple[np.ndarray, List[np.ndarray]]:
        hidden = [batch.copy()]
        for i in range(len(self.__layers)):
            batch = np.matmul(batch, self.__layers[i][0]) + self.__layers[i][1]
            if i < len(self.__layers) - 1:
                batch = np.maximum(batch, 0)
            # Store the forward pass hidden values for use in backprop
            hidden.append(batch.copy())
        return batch, hidden
    
    def __calculate_loss(self,actual: np.ndarray, predicted: np.ndarray) -> np.ndarray:
        "mse"
        return predicted - actual
    
    
    def __calculate_mse(self, actual: np.ndarray, predicted: np.ndarray) -> np.ndarray:
        return (actual - predicted) ** 2
    
    def __backward(self, hidden: List[np.ndarray], grad: np.ndarray) -> None:
        for i in range(len(self.__layers)-1, -1, -1):
            if i != len(self.__layers) - 1:
                grad = np.multiply(grad, np.heaviside(hidden[i+1], 0))
    
            w_grad = hidden[i].T @ grad
            b_grad = np.mean(grad, axis=0)
    
            self.__layers[i][0] -= w_grad * self._learning_rate
            self.__layers[i][1] -= b_grad * self._learning_rate
            
            grad = grad @ self.__layers[i][0].T
        return
```

Let’s generate some dummy data to test the Neural.

Давайте создадим фиктивные данные для тестирования класса Neural.


```
def generate_data():
    # Define correlation values
    corr_a = 0.8
    corr_b = 0.4
    corr_c = -0.2
    
    # Generate independent features
    a = np.random.normal(0, 1, size=100000)
    b = np.random.normal(0, 1, size=100000)
    c = np.random.normal(0, 1, size=100000)
    d = np.random.randint(0, 4, size=100000)
    e = np.random.binomial(1, 0.5, size=100000)
    
    # Generate target feature based on independent features
    target = 50 + corr_a*a + corr_b*b + corr_c*c + d*10 + 20*e + np.random.normal(0, 10, size=100000)
    
    # Create DataFrame with all features
    df = pd.DataFrame({'a': a, 'b': b, 'c': c, 'd': d, 'e': e, 'target': target})
    return df
```

And the client code:
И клиентский код:


```
df = generate_data()

# Separate the features and target
X = df.drop('target', axis=1)
y = df['target']

scaler = StandardScaler()
X = scaler.fit_transform(X)

# Split the dataset into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
y_train = y_train.to_numpy().reshape(-1,1)
y_test = y_test.to_numpy().reshape(-1,1)

layer_structure = [X_train.shape[1],10,10,1]
nn = Neural(layer_structure, 20, 1e-5, 64, 0.2, 1)

nn.fit(X_train, y_train)

y_pred = nn.predict(X_test)
nn.plot_learning()

print("Test error: ",mean_squared_error(y_test, y_pred))

"""
Epoch: 0 Train MSE: 6066.584227303851 Valid MSE: 5630.972575612107
Epoch: 1 Train MSE: 5870.45827241517 Valid MSE: 5384.024826048717
Epoch: 2 Train MSE: 5584.489636840577 Valid MSE: 4993.952466830458
Epoch: 3 Train MSE: 5127.64238543267 Valid MSE: 4376.563641292963
Epoch: 4 Train MSE: 4408.550555767417 Valid MSE: 3470.967255214888
Epoch: 5 Train MSE: 3370.6165240733935 Valid MSE: 2333.4365011529103
Epoch: 6 Train MSE: 2112.702666917853 Valid MSE: 1245.1547720938968
Epoch: 7 Train MSE: 1001.3618108374816 Valid MSE: 565.5834115291266
Epoch: 8 Train MSE: 396.9514096548994 Valid MSE: 298.31216370120575
Epoch: 9 Train MSE: 198.29006090703072 Valid MSE: 204.83294115572235
Epoch: 10 Train MSE: 139.2931182121901 Valid MSE: 162.0341771457693
Epoch: 11 Train MSE: 113.971621253487 Valid MSE: 138.35491897074462
Epoch: 12 Train MSE: 100.19734344395454 Valid MSE: 124.60170156400542
Epoch: 13 Train MSE: 92.35069581444299 Valid MSE: 116.55999261926036
Epoch: 14 Train MSE: 87.88890529435344 Valid MSE: 111.85169154584908
Epoch: 15 Train MSE: 85.37162170152865 Valid MSE: 109.08001681897412
Epoch: 16 Train MSE: 83.96135084225956 Valid MSE: 107.42929147368837
Epoch: 17 Train MSE: 83.17564386183105 Valid MSE: 106.42800615549532
Epoch: 18 Train MSE: 82.73977092210092 Valid MSE: 105.80581167903857
Epoch: 19 Train MSE: 82.49876360284046 Valid MSE: 105.40815905002043
Test error: 105.40244085384184
"""
```


And the learning curve:

![learning curves](./resources/code1.webp)

We have explored the step-by-step process of building a neural network from scratch using Python. This hands-on guide has provided a lean and simple implementation, allowing us to gain a fundamental understanding of neural network architectures. However, it’s important to note that this is just the beginning of our journey into the vast world of neural networks. There are numerous advanced concepts and techniques yet to be explored.


Мы шаг за шагом изучили процесс создания нейронной сети с нуля на Python.  Это практическое руководство предоставило простой и понятный пример, позволив нам получить фундаментальные знания об архитектуре нейронных сетей. Однако важно отметить, что это всего лишь начало нашего путешествия в огромный мир нейронных сетей. Существует множество передовых концепций и методов, которые еще предстоит изучить.

